이른 리턴(early return)의 이점

이른 리턴(early return)을 쓰는 게 좋을까 피하는 게 좋을까? 요약: 중첩된 분기를 줄이기 위해 이른 리턴(early return), 사전조건(precondition)을 명시하고 정상 경로를 분리하기 위해 Guard clauses를 쓰길 권장한다. 다만 그 전에, 되도록 분기 자체를 제거할 방법이 있는지를 먼저 생각해보는 게 좋다.

‘좋다’의 기준

‘좋다’라는 게 코드의 심미성에 대한 논의로 흐르면 지나치게 주관적이거나 사변적일 수 있으니 객관적인 지표에 기반하여 생각해보자. 두 가지 기준이 떠오른다.

  • 이해가능성: 어떤 코드가 인간이 읽고 이해하기에 더 용이한가.
  • 테스트 가능성: 어떤 코드가 더 테스트하기 용이한가.

반세기 전(1960년대)에는 이런 논의를 할 때 성능도 중요한 문제였으나(예를 들어 GOTO를 제거하고 구조적 프로그래밍을 도입하는 논의가 한창일 때, 중첩된 루프에서 한번에 빠져나오는 특정한 상황에서 GOTO를 써야만 가장 효율적으로 빠져나올 수 있다는 주장이 있었다), 지금은 당시에 비해 상황이 많이 바뀌었으니 성능은 고려하지 않기로 한다.

순환복잡도 cyclomatic complexity

테스트 가능성과 이해가능성을 정량화하기 위한 지표로 1976년에 순환복잡도가 제안됐고, 제법 널리 쓰이고 있다. 자바스크립트를 쓴다면 ESLint의 “complexity” 규칙으로 순환복잡도를 제한할 수 있다. (단 기본값이 20으로 설정되어 있는데 이는 지나치게 느슨하다.)

순환복잡도는 코드의 선형적인 실행 경로(linearly independent paths)가 몇 개인지를 정량화한다. 가장 낮은 점수는 1점이다(분기가 하나도 없으면 경로가 1개이므로).

예를 들어 다음 코드의 순환복잡도는 3점이다:

function doSomething(x, y) { // + 1 (default)
  if(x) { // + 1
    if(y) { // + 1
      return 1
    } else {
      return 2
    }
  } else {
    return 3
  }
}

중첩을 제거하기 위해 이른 리턴(early return)을 쓰면 어떨까?

function doSomthing(x, y) { // +1 (default)
  if(!x) return 3 // +1
  if(y) return 1 // +1
  return 2
}

읽기 한결 편해졌지만 여전히 3점이다. 만연한 오해와 달리 이른 리턴(early return) 또는 Guard clauses는 순환복잡도에 영향을 주지 않는다.

이처럼 순환복잡도 점수랑 실제 이해가능성 사이에 관련성이 낮은 경우가 제법 있다. 이는 순환복잡도의 단점 중 하나다.

인지복잡도 cognitive complexity

이러한 단점을 개선하기 위한 제안 중 하나가 인지복잡도가 있다. 2016년에 처음 제안된 후1 꾸준히 갱신되고 있다.2 ESLint를 쓴다면 eslint-plugin-sonarjs를 설치하고 “cyclomatic-complexity” 규칙을 설정하면 된다.

인지복잡도는 순환복잡도와 달리 중첩된 분기에 가중치를 부여한다. 중첩 수준이 높아질수록 추가로 1점씩 더 높아진다. 예를 들어 아래 코드의 순환복잡도는 4점이었지만 인지복잡도는 6점이다. (인지복잡도는 기본점수가 0점에서 시작하므로 7점이 아니라 6점)

function doSomething(x, y, z) {
  if(x) { // + 1
    if(y) { // + 2
      if(z) { // + 3
        return true
      }
    }
  }
  return false
}

위 코드를 아래와 같이 고치면 인지복잡도가 3점으로 낮아진다. (반면 순환복잡도는 여전히 4점)

function doSomething(x, y, z) {
  if(!x) return false; // +1
  if(!y) return false; // +1
  if(!z) return false; // +1
  return true
}

위 코드는 아래와 같이 더 축약할 수 있는데, 이렇게 하면 인지복잡도는 1점이 된다. (순환복잡도는 여전히 4점)

function doSomething(x, y, z) {
  if(!x || !y || !z) return false; // +1
  return true
}

1점인 이유는 분기문의 조건절에 동일한 논리 연산자 연속적으로 쓰이는 경우 추가적인 점수를 부여하지 않기 때문이다. 위 식에서는 논리합 연산자(||)가 반복적으로 쓰이기 때문에 전체 조건문이 1점으로 간주된다. 이는 인지복잡도가 실질적인 이해가능성을 고려하기 위해 설계되었기 때문이다. 반면 순환복잡도는 여전히 4점인데 그 이유는 조건절 안에 담긴 x, y, z가 각각 1점씩으로 계산되기 때문이다.

한편, 위 코드는 아래와 같이 더 축약할 수 있다.

function doSomething(x, y, z) {
  return x && y && z; // +1
}

중간 요약:

  • 이른 리턴(early return)은 중첩된 분기를 평평하게 만들어주기 때문에 이해가능성을 높여줄 수 있다.
  • 다만 순환복잡도 지표에서는 차이가 없고 인지복잡도 등 중첩된 분기에 가중치를 부여하는 지표를 쓸 경우에만 정량화할 수 있다.
  • 분기 커버리지가 아니라 이해가능성을 정량화하고자 하는 맥락이라면 순환복잡도보다는 인지복잡도를 쓰는 게 좋다.

테스트 가능성

이제 테스트 가능성 얘기를 해보자. 테스트 가능성은 어떤 모듈을 테스트하기가 얼마나 용이한가를 나타내는데, ‘용이하다’에는 여러 의미가 있다.

  • 함수에 인자가 많거나 각 인자를 생성하기가 까다로우면 해당 함수를 테스트하기가 어려워진다. 이런 경우에 Mock object 등을 쓰는데, 이는 종종 설계 문제일 수 있다. 참고: Mock object smell
  • 함수에 분기가 많으면 커버리지를 높이기 위해 더 많은 테스트 케이스가 필요해진다.
  • 함수의 정상 경로(happy path)와 나머지 경로가 명시적으로 구분되어 있으면 테스트 케이스 작성이 편해진다.

이른 리턴(early return) 이야기를 하는 맥락이니 우리 관심사는 분기와 경로다.

우선, 모든 분기가 테스트에 의해 커버되었는지를 평가하는 기준인 분기 커버리지를 생각해보자. 분기 커버리지 관점에서 필요한 테스트 케이스의 수는 순환복잡도 점수와 대체로 일치한다.

doSomething() 예시의 경우, CC가 4점이었고, 아래 네 개의 테스트 케이스가 필요하다:

  • 모두 true인 경우 true를 반환하는지 확인
  • xfalse인 경우 false를 반환하는지 확인
  • yfalse인 경우 false를 반환하는지 확인
  • zfalse인 경우 false를 반환하는지 확인

그런데 앞서 살펴본 바와 같이 이른 리턴(early return)은 순환복잡도 점수에 영향을 주지 않는다. 따라서 이른 리턴을 쓴다고 해서 분기 커버리지를 높이기 위한 테스트 케이스 수가 줄어들지도 않는다.

테스트 케이스의 개수를 유지하며 개별 함수의 테스트 가능성을 높이려면 함수를 잘게 나눠야 한다(Extract function). 함수를 잘게 나누면 분기 커버리지보다는 경로 커버리지를 낮추는데 도움이 된다. 경로 커버리지란 개별 분기가 아니라 분기의 조합(즉 함수의 전체 실행 경로)에 대한 커버리지를 말한다.

로직을 일반화하여 분기 자체를 제거하는 것도 좋은 방법이지만 이른 리턴(early return)과 직접 관련이 있지는 않다.

다음으로, 정상 경로가 명확히 구분되는 함수의 테스트 가능성에 대해 생각해보자. Guard clauses를 사용하여 사전조건(precondition)을 점검하여 예외 경로와 정상 경로를 명확히 구분하는 방식은 (비록 순환복잡도에는 영향을 주지 않지만) 테스트 케이스 작성에 도움을 주고 실제 코드와 테스트 케이스 사이의 대응을 좀 더 명시적으로 드러내줄 수 있다는 점에서 분명히 장점이 있다.

분기 자체를 제거하기

이른 리턴(early return)은 반드시 분기를 가정한다. 분기 자체는 놔두고 분기를 어떻게 처리할 것인지를 따지는 것도 의미있지만, 제일 좋은 건 분기를 제거해버리는 방법을 찾는거다.

객체지향프로그래밍을 한다면 조건문을 다형성으로 바꾸는 리팩토링을 할 수도 있고, Null object pattern을 고려해도 좋다(사실 Null object pattern은 조건문을 다형성으로 바꾸는 리팩토링의 특수한 사례다).

또는 수학적인 또는 논리적인 일반화를 통해 로직을 단순화시키는 방법도 있다. 간단한 예로 이런 코드는…

dir++;
if(dir >= 360) dir = 0;

…이렇게 바꿀 수 있다(나머지 연산자로 순환 개념을 표현하기):

dir++;
dir %= 360

생각보다 많은 경우에 함수 내에서 분기를 아예 없애거나 하나의 함수가 수식 한 개로 표현되는 형태로 코드를 개선하는 게 가능하다.

하나의 함수에는 하나의 return만?

한편, 함수 하나에는 하나의 return만 있는 게 좋다는 주장도 오래 전부터 있었는데, 켄트 벡은 이런 주장은 더이상 유효하지 않다고 말한다.3

하나릐 루틴에 하나의 return만 있어야 한다는 “규칙”은 FORTRAN 시절에 유래했는데, FORTRAN에서는 하나의 루틴에 여러 진입점과 반환점이 있을 수 있었기 때문이다. 이런 코드를 디버깅하기란 거의 불가능하다. 어떤 문장이 실행되는지 알 수가 없기 때문이다.

The “rule” about having a single return for a routine came from the days of FORTRAN, where a single routine could have multiple entry and exit points. It was nearly impossible to debug such code. You couldn’t tell what statements were executed.

결론

이해가능성 측면:

  • 이른 리턴(early return)은 중첩된 분기를 평평하게 만들어주기 때문에 이해가능성을 높여줄 수 있다.
  • 다만 순환복잡도 지표에서는 차이가 없고 인지복잡도 등 중첩된 분기에 가중치를 부여하는 지표를 쓸 경우에만 정량화할 수 있다.
  • 분기 커버리지가 아니라 이해가능성을 정량화하고자 하는 맥락이라면 순환복잡도보다는 인지복잡도를 쓰는 게 좋다.

테스트 가능성 측면:

  • 분기 커버리지 관점에서는 이른 리턴(early return)이 테스트 가능성을 높여주지는 않는다.
  • Guard clauses를 써서 정상 경로를 분리하고 중첩된 분기를 평평하게 만들면 코드의 실행 경로가 더 쉽게 드러나므로 테스트 작성에 도움이 될 수 있다.

이런 이유에서 나는 중첩된 분기를 평평하게 만들기 위한 용도의 이른 리턴(early return)과, 사전조건(precondition)을 명확히 하고 정상 경로를 깔끔하게 분리하기 위한 Guard clauses를 권장한다. 다만 그 전에, 되도록 분기 자체를 제거할 방법이 있는지를 먼저 생각해보는 게 좋다.

Footnotes

  1. Cognitive Complexity, Because Testability != Understandability | Sonar

  2. Cognitive complexity: a new way of measuring understandability

  3. Tidy first? A personal exercise in empirical software design

2024 © ak